Skip to content

[client] Add NAT-PMP/UPnP support#5202

Merged
lixmal merged 13 commits intomainfrom
nat-pmp-upnp
Apr 8, 2026
Merged

[client] Add NAT-PMP/UPnP support#5202
lixmal merged 13 commits intomainfrom
nat-pmp-upnp

Conversation

@lixmal
Copy link
Copy Markdown
Collaborator

@lixmal lixmal commented Jan 28, 2026

Describe your changes

This adds a NAT mapper that tries to discover NAT-PMP and UPnP gateways to acquire a mapping for a forwarded port.
This mapping is then signaled to other peers.

Example logs

2026-01-28T14:50:22Z INFO client/internal/portforward/manager.go:189: created port mapping: 51820 -> 50497 via NAT-PMP (external IP: 45.45.45.2)

2026-01-28T14:50:38Z DEBG [peer: Fc5Y0orhGvvoTD9BDpwUpi9h8O6icW29EzJumvt7izg=] client/internal/peer/worker_ice.go:407: injecting port-forwarded candidate: udp4 srflx 45.45.45.2:50497 related 45.45.45.2:51820 (resolved: 45.45.45.2:50497) candidate:XnfKu4TIX7PFxUgCB/AWryc5TRVvPosU (mapping: 51820 -> 50497 via NAT-PMP, priority: 1694499815)

Issue ticket number and link

Stack

Checklist

  • Is it a bug fix
  • Is a typo/documentation fix
  • Is a feature enhancement
  • It is a refactor
  • Created tests that fail without the change (if possible)

By submitting this pull request, you confirm that you have read and agree to the terms of the Contributor License Agreement.

Documentation

Select exactly one:

  • I added/updated documentation for this change
  • Documentation is not needed for this change (explain why)

Docs PR URL (required if "docs added" is checked)

Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

Summary by CodeRabbit

  • New Features

    • Port‑forwarding manager: NAT discovery, UDP mapping creation/renewal, env toggle, crash‑recovery state, and graceful lifecycle tied to engine/peer.
    • ICE now injects port‑forwarded server‑reflexive candidates to improve connectivity.
    • JS/WASM builds include a no‑op port‑forwarding stub for parity.
  • Tests

    • Unit tests covering manager mapping lifecycle, availability, and state cleanup.
  • Chores

    • Added NAT/UPnP dependencies and clarified state registration comments.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 28, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Engine now owns a PortForward Manager lifecycle for NAT discovery, UDP port mapping creation/renewal/cleanup and persisted state; WorkerICE can inject a port‑forwarded Server‑Reflexive candidate once per session when a mapping is available.

Changes

Cohort / File(s) Summary
Engine & Manager
client/internal/engine.go, client/internal/portforward/manager.go, client/internal/portforward/manager_js.go
Engine gains portForwardManager *portforward.Manager; manager created in NewEngine, started in Start (goroutine) and stopped in close. Adds full Go port‑forward Manager (discover/create/renew/cleanup/persist) plus JS/WASM no‑op stub.
Peer / Conn Wiring
client/internal/peer/conn.go
Added PortForwardManager *portforward.Manager to ServiceDependencies and portForwardManager field to Conn; wired into NewConn so peer code can access the manager.
ICE Worker: Candidate Injection
client/internal/peer/worker_ice.go
Adds portForwardAttempted flag; on ServerReflexive candidates calls injectPortForwardedCandidate to query manager and, if mapping exists, build and signal a forwarded SRFLX candidate once per session; resets flag on agent recreation.
Port Forward State & Cleanup
client/internal/portforward/state.go
New persisted State{InternalPort,Protocol} with Name() and Cleanup() to remove residual mappings via discovered gateway (with test hook for discovery).
Env Toggle
client/internal/portforward/env.go
Adds isDisabledByEnv() reading NB_DISABLE_NAT_MAPPER to opt out of manager behavior via env.
Tests
client/internal/portforward/manager_test.go
Unit tests with a mockNAT: mapping creation, GetMapping/IsAvailable, state cleanup, and state name verification.
Server State Registration Docs
client/server/state_generic.go, client/server/state_linux.go
Added comments clarifying portforward.State is not registered at startup; registered config.ShutdownState.
Deps
go.mod
Added NAT traversal dependencies (github.com/libp2p/go-nat, plus UPnP/PMP/SSDP libs as indirects).

Sequence Diagram(s)

sequenceDiagram
    participant Engine
    participant PFManager as PortForwardManager
    participant StateMgr as StateManager
    participant NAT as NAT_Gateway

    Engine->>PFManager: Start(ctx, wgPort)
    PFManager->>StateMgr: Load persisted state
    StateMgr-->>PFManager: State (maybe nil)
    PFManager->>NAT: Discover gateway
    NAT-->>PFManager: Gateway reference
    alt residual state present
        PFManager->>NAT: Delete prior mapping (cleanupResidual)
        NAT-->>PFManager: Deletion result
    end
    PFManager->>NAT: AddPortMapping(UDP, internalPort)
    NAT-->>PFManager: ExternalPort, ExternalIP
    PFManager->>StateMgr: Persist mapping details
    PFManager->>PFManager: Start renewLoop()
Loading
sequenceDiagram
    participant ICEAgent as ICE_Agent
    participant WorkerICE
    participant PFManager as PortForwardManager
    participant Signaler

    ICEAgent->>WorkerICE: onICECandidate(srflxCandidate)
    WorkerICE->>WorkerICE: if not portForwardAttempted
    alt mapping available
        WorkerICE->>PFManager: GetMapping()
        PFManager-->>WorkerICE: Mapping (external IP/port)
        WorkerICE->>WorkerICE: createForwardedCandidate(srflxCandidate, mapping)
        WorkerICE->>Signaler: Signal forwarded candidate
        WorkerICE->>WorkerICE: set portForwardAttempted = true
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • pappz
  • mlsmaycon

Poem

🐰
I hopped through NAT and tide,
Found a port where packets hide,
Mapped a tunnel, bright and sly,
A forwarded candidate waved hi,
Hooray — connections multiply! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title '[client] Add NAT-PMP/UPnP support' clearly and concisely summarizes the main feature addition across all changed files.
Description check ✅ Passed The PR description covers the main change, explains the feature, includes log examples, and completes the required checklist section with documentation noted as not needed.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch nat-pmp-upnp

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@client/internal/peer/worker_ice.go`:
- Line 431: Add a brief comment above the line setting priority :=
srflxCandidate.Priority() + 1000 explaining why a fixed +1000 offset is applied
for port-forwarded candidates: state that the offset intentionally increases
priority to favor port-forwarded srflx candidates during connectivity checks,
note that +1000 is an arbitrary chosen boost value (or reference the design
doc/issue if applicable), and describe how this interacts with the RFC 8445
priority formula (i.e., it is an additional global boost on top of the
candidate.Priority() computed per RFC 8445); optionally mention that a future
change could recompute priority using
type-preference/local-preference/component-ID components instead of a fixed
offset.

In `@client/internal/portforward/manager_test.go`:
- Around line 142-151: TestState_Cleanup calls State.Cleanup which triggers real
NAT/gateway discovery (causing slow/flaky tests); stub out the discovery used by
Cleanup in this unit test (e.g., override the package-level discover function or
inject a fake gateway) to return a no-op gateway or nil mappings and restore the
original after the test so Cleanup runs deterministically and quickly; update
TestState_Cleanup to replace the discovery function before calling state.Cleanup
and to restore it in a defer.

In `@client/internal/portforward/manager.go`:
- Around line 54-76: The Start method currently casts wgPort to uint16 without
validation which can wrap invalid values; add a guard at the top of
Manager.Start to validate wgPort is between 1 and 65535 before assigning
m.wgPort: if out of range, log an error (using log.Errorf or log.Infof) with
context (include the wgPort value) and return without starting the mapper. Keep
the existing early return for isDisabledByEnv() and retain use of m.ctx,
m.cancel, m.stateManager.RegisterState and go m.run() only after the port check
passes.
- Around line 195-212: The Stop method on Manager currently cancels work and
persists state but doesn't remove the active port mapping; modify Manager.Stop
to perform a best-effort deletion of the port mapping before returning and to
clear persisted state so the mapping doesn't linger until TTL expiry.
Specifically, in Manager.Stop (which uses m.mu, m.cancel, m.wg) call the
existing mapping-removal routine (or implement a call like
deletePortMapping/removeMapping) after cancel() and wg.Wait() (or immediately
after cancel() if safe), handle and log any error as non-fatal, then clear
in-memory/persisted state by calling persistStateLocked to write an
empty/cleared state (or a new clearPersistedState function) while holding m.mu;
ensure m.cancel is nil and any mapping metadata is removed so subsequent
restarts don't re-persist the mapping.
- Around line 251-275: renewMapping updates m.mapping.ExternalPort but never
causes the ICE agent to refresh candidates, leaving injected server-reflexive
candidates stale; detect when uint16(externalPort) != m.mapping.ExternalPort in
renewMapping and, after updating m.mapping.ExternalPort, either reset the
port-forward state (clear portForwardAttempted) and call the same injection path
used in worker_ice.go (invoke injectPortForwardedCandidate or the routine that
creates/injects candidates), or trigger the ICE agent to re-gather (call the
agent's Gather/Restart method) so new candidates reflecting the updated external
port are signaled; update renewMapping to perform one of these actions and
ensure it uses the same ctx/locking semantics as the current function.

In `@go.mod`:
- Line 66: Update the two outdated NAT traversal dependencies: bump
github.com/huin/goupnp from v1.2.0 to v1.3.0 and github.com/koron/go-ssdp from
v0.0.4 to v0.1.0 in go.mod (or run go get github.com/huin/goupnp@v1.3.0
github.com/koron/go-ssdp@v0.1.0), then run go mod tidy to update the lockfile
and ensure builds pass; while here, confirm that github.com/libp2p/go-nat is
still the intended NAT provider for the project (as libp2p has moved NAT
functionality into go-libp2p) and adjust/remove it if necessary.

Comment thread client/internal/peer/worker_ice.go
Comment thread client/internal/portforward/manager_test.go
Comment thread client/internal/portforward/manager.go Outdated
Comment thread client/internal/portforward/manager.go Outdated
Comment thread client/internal/portforward/manager.go Outdated
Comment thread go.mod
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@client/internal/portforward/manager.go`:
- Around line 219-222: The early return after calling
m.gateway.DeletePortMapping leaves m.mapping set and skips persisting state;
change the flow so DeletePortMapping errors are logged but do not return
early—always clear m.mapping and call the manager's persistence routine (e.g.,
m.persist / m.saveState or the existing state-save method) so in-memory state
and on-disk state are consistent; easiest is to log the error from
m.gateway.DeletePortMapping(ctx, m.mapping.Protocol,
int(m.mapping.InternalPort)) and then continue to set m.mapping = nil and invoke
the state persistence method (or ensure a defer clears/persists) before
finishing.
🧹 Nitpick comments (1)
client/internal/portforward/manager.go (1)

56-78: Consider validating wgPort is non-zero.

While wgPort is now typed as uint16 (avoiding the casting issue from previous review), a value of 0 would still create an invalid mapping. Consider adding validation:

 func (m *Manager) Start(ctx context.Context, wgPort uint16) {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
 	if m.cancel != nil {
 		return
 	}
 
 	if isDisabledByEnv() {
 		log.Infof("NAT port mapper disabled via %s", envDisableNATMapper)
 		return
 	}
 
+	if wgPort == 0 {
+		log.Warnf("invalid WireGuard port 0; skipping NAT mapping")
+		return
+	}
+
 	m.ctx, m.cancel = context.WithCancel(ctx)

Comment thread client/internal/portforward/manager.go Outdated
Comment thread client/internal/portforward/manager.go Outdated
Comment thread client/internal/portforward/manager.go Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@client/internal/portforward/manager.go`:
- Around line 153-185: createMapping (and similarly renewMapping) currently
holds m.mu while calling blocking NAT ops (AddPortMapping/GetExternalAddress),
preventing Stop() from acquiring the lock; fix by copying the minimal state
under lock (e.g., gateway, wgPort, mappingDescription, defaultMappingTTL),
release m.mu, perform the network calls with a cancellable ctx, then re-lock
m.mu and verify the gateway/state is still valid before updating m.mapping and
calling persistStateLocked(); ensure you follow the same pattern in renewMapping
and avoid calling AddPortMapping/GetExternalAddress while m.mu is held so Stop()
can promptly cancel the context.

Comment thread client/internal/portforward/manager.go Outdated
@sonarqubecloud
Copy link
Copy Markdown

Comment thread client/internal/portforward/manager.go
Comment thread client/internal/peer/worker_ice.go Outdated
Comment thread client/internal/portforward/manager.go Outdated
Comment thread client/internal/portforward/manager.go Outdated
Comment thread client/internal/portforward/manager.go Outdated
Replace async goroutine-based cleanup with a synchronous flow where
Start runs cleanup inline after renewLoop exits. Use a stopCtx channel
so GracefullyStop can pass its deadline-bounded context to Start's
cleanup path. When no graceful stop occurs, Start fires cleanup in a
background goroutine with a 10s timeout.

Also fix GetMapping double Lock, renewMapping referencing undefined m.mu,
cleanup referencing undefined variables, remove statemanager dependency,
and align manager_js.go stub signatures.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/internal/peer/worker_ice.go`:
- Around line 379-395: The code sets w.portForwardAttempted too early; move the
assignment so it is only set after a valid mapping is available: first check
pfManager != nil and mapping := pfManager.GetMapping() != nil, then acquire
w.muxAgent lock and check/assign w.portForwardAttempted (to avoid races) and
release the lock; keep the early-return behavior if portForwardAttempted is
already true, but perform that check inside the locked section after confirming
mapping exists so an initial SRFLX candidate doesn't permanently disable port
forwarding. Reference symbols: w.muxAgent, w.portForwardAttempted, pfManager,
pfManager.GetMapping().

In `@client/internal/portforward/manager_test.go`:
- Around line 149-164: The test currently asserts the mapping is absent after
calling State.Cleanup() without first ensuring a mapping existed; update
TestState_Cleanup to seed the mock gateway with a mapping for the port under
test (e.g., set mockGateway.mappings[51820] = some mapping) and assert it exists
before calling state.Cleanup(), then call state.Cleanup() and assert the mapping
is removed; reference the mockGateway.mappings map and the State.Cleanup() call
so the test fails if deletion regresses.
- Around line 71-143: Update the tests to match the current Manager API: call
NewManager() with no arguments (replace NewManager(sm) with NewManager()), call
createMapping with the correct signature (e.g.,
m.createMapping(context.Background(), newMockNAT()) instead of
m.createMapping(nil)), and replace uses of the non-existent m.IsAvailable() with
explicit checks against the current API (for example assert availability by
testing m.GetMapping() != nil and m.gateway != nil or equivalent conditions).
Ensure references to Mapping, gateway, GetMapping and createMapping are used so
the tests compile against the existing Manager implementation.

In `@client/internal/portforward/manager.go`:
- Around line 239-250: Manager.cleanup leaves m.mapping populated after
successfully deleting the port on the gateway, so callers of GetMapping() can
return a stale mapping; update Manager.cleanup (the method named cleanup on type
Manager) to set m.mapping = nil after a successful gateway.DeletePortMapping
call (after the log.Infof or just before returning) so the in-memory state
reflects the remote deletion and GetMapping() no longer returns the removed
mapping.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8750956d-df33-4362-89f6-0743dce1e8c8

📥 Commits

Reviewing files that changed from the base of the PR and between 3f93b5d and 4d6b810.

📒 Files selected for processing (7)
  • client/internal/dns/local/local_test.go
  • client/internal/engine.go
  • client/internal/peer/worker_ice.go
  • client/internal/portforward/env.go
  • client/internal/portforward/manager.go
  • client/internal/portforward/manager_js.go
  • client/internal/portforward/manager_test.go
✅ Files skipped from review due to trivial changes (1)
  • client/internal/dns/local/local_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/internal/engine.go

Comment thread client/internal/peer/worker_ice.go Outdated
Comment thread client/internal/portforward/manager_test.go Outdated
Comment thread client/internal/portforward/manager_test.go
Comment thread client/internal/portforward/manager.go
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Apr 8, 2026

@lixmal lixmal merged commit d33cd4c into main Apr 8, 2026
57 of 58 checks passed
@lixmal lixmal deleted the nat-pmp-upnp branch April 8, 2026 07:29
@coderabbitai coderabbitai Bot mentioned this pull request Apr 8, 2026
7 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants